@thallylabs/migrate 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -66,6 +66,10 @@ interface MigrationDocsConfig {
66
66
  display?: 'tabs' | 'dropdown';
67
67
  };
68
68
  theme?: 'default' | 'maple' | 'sharp' | 'minimal';
69
+ /** Icon set for content `icon` names; Mintlify's `icons.library` carries through. */
70
+ icons?: {
71
+ library?: 'lucide' | 'fontawesome' | 'tabler';
72
+ };
69
73
  banner?: MigrationBannerConfig;
70
74
  navbar?: MigrationNavbarConfig;
71
75
  footer?: MigrationFooterConfig;
package/dist/index.js CHANGED
@@ -1104,8 +1104,30 @@ function normalizeDocusaurusAdmonitions(body) {
1104
1104
  return line;
1105
1105
  }).join("\n");
1106
1106
  }
1107
+ var GLOBAL_DOCUSARUS_COMPONENTS = /* @__PURE__ */ new Set([
1108
+ "Tabs",
1109
+ "TabItem",
1110
+ "Link",
1111
+ "DocCardList",
1112
+ "TOCInline"
1113
+ ]);
1114
+ function isGlobalDocusaurusImport(line) {
1115
+ const trimmed = line.trim().replace(/;$/, "").trimEnd();
1116
+ if (!trimmed.startsWith("import ")) return false;
1117
+ const separator = trimmed.indexOf(" from ", "import ".length);
1118
+ if (separator < 0) return false;
1119
+ const component = trimmed.slice("import ".length, separator).trim();
1120
+ if (!GLOBAL_DOCUSARUS_COMPONENTS.has(component)) return false;
1121
+ const source = trimmed.slice(separator + " from ".length);
1122
+ if (source.length < 3) return false;
1123
+ const quote = source[0];
1124
+ if (quote !== "'" && quote !== '"' || source.at(-1) !== quote) return false;
1125
+ const moduleName = source.slice(1, -1);
1126
+ return moduleName.startsWith("@theme/") || moduleName.startsWith("@docusaurus/");
1127
+ }
1107
1128
  function normalizeMdx(body) {
1108
- return normalizeDocusaurusAdmonitions(body).replace(/^import\s+(?:Tabs|TabItem|Link|DocCardList|TOCInline)\s+from\s+['"]@(?:theme|docusaurus)\/[^'"]+['"]\s*;?\s*$/gm, "").replace(/<TabItem\b([^>]*)>/g, (_match, attributes) => {
1129
+ const withoutGlobalImports = body.split("\n").filter((line) => !isGlobalDocusaurusImport(line)).join("\n");
1130
+ return normalizeDocusaurusAdmonitions(withoutGlobalImports).replace(/<TabItem\b([^>]*)>/g, (_match, attributes) => {
1109
1131
  const title = attributes.match(/\blabel=(?:"([^"]*)"|'([^']*)')/)?.slice(1).find(Boolean) ?? attributes.match(/\bvalue=(?:"([^"]*)"|'([^']*)')/)?.slice(1).find(Boolean) ?? "Tab";
1110
1132
  return `<Tab title="${title.replace(/"/g, "&quot;")}">`;
1111
1133
  }).replace(/<\/TabItem>/g, "</Tab>").replace(/<Link\b([^>]*)\bto=(?:"([^"]*)"|'([^']*)')([^>]*)>/g, (_match, before, doubleQuoted, singleQuoted, after) => `<a${before}href="${doubleQuoted ?? singleQuoted}"${after}>`).replace(/<\/Link>/g, "</a>").replace(/<(?:DocCardList|TOCInline)\b[^>]*\/>/g, "").replace(/<!--([\s\S]*?)-->/g, (_match, content) => `{/*${content}*/}`).replace(/<Danger(\s[^>]*)?>/g, "<Error$1>").replace(/<\/Danger>/g, "</Error>").replace(/<Warn(\s[^>]*)?>/g, "<Warning$1>").replace(/<\/Warn>/g, "</Warning>").replace(/<Check(\s[^>]*)?>/g, "<Note$1>").replace(/<\/Check>/g, "</Note>").replace(/<Tree\.Folder/g, "<Folder").replace(/<\/Tree\.Folder>/g, "</Folder>").replace(/<Tree\.File/g, "<File").replace(/<\/Tree\.File>/g, "</File>");
@@ -1571,6 +1593,8 @@ function projectedCompatibleConfig(config) {
1571
1593
  const headingFont = projectedFont(objectValue2(config.fonts)?.heading);
1572
1594
  const feedback = objectValue2(config.feedback);
1573
1595
  const seo = objectValue2(config.seo);
1596
+ const iconLibrary = objectValue2(config.icons)?.library;
1597
+ const projectedIconLibrary = ["lucide", "fontawesome", "tabler"].includes(String(iconLibrary)) ? String(iconLibrary) : void 0;
1574
1598
  const navbar = projectedNavbar(config.navbar);
1575
1599
  const globalLinks = projectedGlobalNavigationLinks(objectValue2(config.navigation)?.global);
1576
1600
  const navbarLinks = [...new Map(
@@ -1579,6 +1603,7 @@ function projectedCompatibleConfig(config) {
1579
1603
  const projectedNavigation = navbarLinks.length > 0 || navbar?.primary ? { ...navbarLinks.length > 0 ? { links: navbarLinks } : {}, ...navbar?.primary ? { primary: navbar.primary } : {} } : void 0;
1580
1604
  return {
1581
1605
  ...projectedTheme(config.theme) ? { theme: projectedTheme(config.theme) } : {},
1606
+ ...projectedIconLibrary ? { icons: { library: projectedIconLibrary } } : {},
1582
1607
  ...bannerContent ? {
1583
1608
  banner: {
1584
1609
  content: bannerContent,
@@ -2670,7 +2695,10 @@ function renderMigrationFiles(bundle, options = {}) {
2670
2695
 
2671
2696
  // src/url.ts
2672
2697
  import { load } from "cheerio";
2698
+ import remarkMdx3 from "remark-mdx";
2699
+ import remarkParse3 from "remark-parse";
2673
2700
  import TurndownService from "turndown";
2701
+ import { unified as unified3 } from "unified";
2674
2702
  import { parse as parseYaml3, stringify as stringifyYaml } from "yaml";
2675
2703
  var DEFAULT_MAX_PAGES = 1e3;
2676
2704
  var DEFAULT_MAX_TOTAL_BYTES = 1e8;
@@ -2773,6 +2801,8 @@ var LOCALE_CODES = /* @__PURE__ */ new Set([
2773
2801
  "zh-hant"
2774
2802
  ]);
2775
2803
  var PORTABLE_URL_PROPS = "href|src|img|primaryHref|secondaryHref";
2804
+ var PORTABLE_URL_PROP_NAMES = new Set(PORTABLE_URL_PROPS.split("|"));
2805
+ var remoteMarkdownParser = unified3().use(remarkParse3).use(remarkMdx3);
2776
2806
  function validateMigrationUrl(value) {
2777
2807
  let url;
2778
2808
  try {
@@ -2902,7 +2932,7 @@ function detectUrlPlatform(document) {
2902
2932
  const value = `${document.body.slice(0, 2e5)} ${headers}`.toLowerCase();
2903
2933
  if (value.includes("__mintlify") || value.includes("/mintlify-assets/") || value.includes("x-mintlify-") || value.includes("/_mintlify/")) return "mintlify";
2904
2934
  if (value.includes("docusaurus") || value.includes("__docusaurus")) return "docusaurus";
2905
- if (value.includes("gitbook") || value.includes("gitbook.io")) return "gitbook";
2935
+ if (value.includes("gitbook")) return "gitbook";
2906
2936
  if (value.includes("nextra")) return "nextra";
2907
2937
  if (value.includes("vitepress")) return "vitepress";
2908
2938
  if (value.includes("starlight")) return "starlight";
@@ -3041,19 +3071,184 @@ function stripEventHandlerProps(line) {
3041
3071
  }
3042
3072
  return cursor === 0 ? line : `${result}${line.slice(cursor)}`;
3043
3073
  }
3044
- function hasUnsafeUrlScheme(value) {
3045
- const normalized = value.trim().replace(/[\u0000-\u0020]/g, "").replace(/&(?:#x0*3a|#0*58|colon);/gi, ":");
3046
- return /^(?:javascript|data|vbscript):/i.test(normalized);
3074
+ function isSafePortableUrl(value) {
3075
+ try {
3076
+ const parsed = new URL(value, "https://relative.thally.invalid");
3077
+ return ["http:", "https:", "mailto:", "tel:"].includes(parsed.protocol);
3078
+ } catch {
3079
+ return false;
3080
+ }
3081
+ }
3082
+ function decodeHtmlAttribute(value) {
3083
+ const document = load(`<i data-thally-value="${value.replace(/"/g, "&quot;")}"></i>`);
3084
+ return document("i").attr("data-thally-value") ?? "";
3085
+ }
3086
+ function escapeMarkdownText(value, escapeTableSeparator = false) {
3087
+ let escaped = "";
3088
+ for (const character of value) {
3089
+ if (character === "\\") escaped += "\\\\";
3090
+ else if (character === "[" || character === "]") escaped += `\\${character}`;
3091
+ else if (character === "<") escaped += "&lt;";
3092
+ else if (character === ">") escaped += "&gt;";
3093
+ else if (character === "{") escaped += "&#123;";
3094
+ else if (character === "}") escaped += "&#125;";
3095
+ else if (character === "|" && escapeTableSeparator) escaped += "\\|";
3096
+ else escaped += character;
3097
+ }
3098
+ return escaped;
3099
+ }
3100
+ function markdownLinkDestination(value) {
3101
+ try {
3102
+ return encodeURI(value).replace(/\(/g, "%28").replace(/\)/g, "%29").replace(/&/g, "&amp;");
3103
+ } catch {
3104
+ return null;
3105
+ }
3106
+ }
3107
+ function htmlOpeningTagEnd(value, start) {
3108
+ let quote = "";
3109
+ for (let index = start; index < value.length; index += 1) {
3110
+ const character = value[index];
3111
+ if (quote) {
3112
+ if (character === quote) quote = "";
3113
+ continue;
3114
+ }
3115
+ if (character === '"' || character === "'") {
3116
+ quote = character;
3117
+ continue;
3118
+ }
3119
+ if (character === ">") return index + 1;
3120
+ }
3121
+ return null;
3122
+ }
3123
+ function replacePortableHtmlImages(value) {
3124
+ const matcher = /<img\b/gi;
3125
+ let cursor = 0;
3126
+ let output = "";
3127
+ for (const match of value.matchAll(matcher)) {
3128
+ const start = match.index;
3129
+ if (start < cursor) continue;
3130
+ const end = htmlOpeningTagEnd(value, start + match[0].length);
3131
+ if (end === null) continue;
3132
+ const fragment = load(value.slice(start, end));
3133
+ const image = fragment("img").first();
3134
+ const source = image.attr("src");
3135
+ const destination = source && isSafePortableUrl(source) ? markdownLinkDestination(source) : null;
3136
+ const alternative = escapeMarkdownText(image.attr("alt") ?? "");
3137
+ output += value.slice(cursor, start);
3138
+ output += destination ? `![${alternative}](${destination})` : alternative;
3139
+ cursor = end;
3140
+ }
3141
+ return cursor === 0 ? value : `${output}${value.slice(cursor)}`;
3142
+ }
3143
+ function replacePortableHtmlAnchors(value) {
3144
+ const matcher = /<a\b/gi;
3145
+ let cursor = 0;
3146
+ let output = "";
3147
+ for (const match of value.matchAll(matcher)) {
3148
+ const start = match.index;
3149
+ if (start < cursor) continue;
3150
+ const openingEnd = htmlOpeningTagEnd(value, start + match[0].length);
3151
+ if (openingEnd === null) continue;
3152
+ const closingMatch = /<\/a\s*>/gi;
3153
+ closingMatch.lastIndex = openingEnd;
3154
+ const closing = closingMatch.exec(value);
3155
+ if (!closing) continue;
3156
+ const end = closing.index + closing[0].length;
3157
+ const fragment = load(value.slice(start, end));
3158
+ const anchor = fragment("a").first();
3159
+ const href = anchor.attr("href");
3160
+ const label = escapeMarkdownText(anchor.text().replace(/\s+/g, " ").trim());
3161
+ const destination = href && isSafePortableUrl(href) ? markdownLinkDestination(href) : null;
3162
+ output += value.slice(cursor, start);
3163
+ output += destination ? `[${label}](${destination})` : label;
3164
+ cursor = end;
3165
+ }
3166
+ return cursor === 0 ? value : `${output}${value.slice(cursor)}`;
3167
+ }
3168
+ function markdownNodeText(node) {
3169
+ if (node.type === "text" || node.type === "inlineCode") return node.value ?? "";
3170
+ if (node.type === "image" || node.type === "imageReference") return node.alt ?? "";
3171
+ return (node.children ?? []).map(markdownNodeText).join("");
3172
+ }
3173
+ function sanitizeMarkdownUrls(value) {
3174
+ let root;
3175
+ try {
3176
+ root = remoteMarkdownParser.parse(value);
3177
+ } catch {
3178
+ return null;
3179
+ }
3180
+ let hasUnsafeMdxSyntax = false;
3181
+ const validateMdxSyntax = (node) => {
3182
+ if (node.type === "mdxTextExpression" || node.type === "mdxFlowExpression" || node.type === "mdxjsEsm") {
3183
+ hasUnsafeMdxSyntax = true;
3184
+ return;
3185
+ }
3186
+ if (node.type === "mdxJsxTextElement" || node.type === "mdxJsxFlowElement") {
3187
+ for (const attribute of node.attributes ?? []) {
3188
+ if (attribute.type !== "mdxJsxAttribute" || !attribute.name || /^on[A-Z]/.test(attribute.name)) {
3189
+ hasUnsafeMdxSyntax = true;
3190
+ return;
3191
+ }
3192
+ let staticValue = attribute.value;
3193
+ if (attribute.value && typeof attribute.value === "object") {
3194
+ if (attribute.value.type !== "mdxJsxAttributeValueExpression") {
3195
+ hasUnsafeMdxSyntax = true;
3196
+ return;
3197
+ }
3198
+ try {
3199
+ staticValue = JSON.parse(attribute.value.value);
3200
+ } catch {
3201
+ hasUnsafeMdxSyntax = true;
3202
+ return;
3203
+ }
3204
+ }
3205
+ if (PORTABLE_URL_PROP_NAMES.has(attribute.name) && (typeof staticValue !== "string" || !isSafePortableUrl(decodeHtmlAttribute(staticValue)))) {
3206
+ hasUnsafeMdxSyntax = true;
3207
+ return;
3208
+ }
3209
+ }
3210
+ }
3211
+ for (const child of node.children ?? []) validateMdxSyntax(child);
3212
+ };
3213
+ validateMdxSyntax(root);
3214
+ if (hasUnsafeMdxSyntax) return null;
3215
+ const unsafeDefinitions = /* @__PURE__ */ new Set();
3216
+ const visitDefinitions = (node) => {
3217
+ if (node.type === "definition" && node.identifier && node.url && !isSafePortableUrl(node.url)) unsafeDefinitions.add(node.identifier.toLowerCase());
3218
+ for (const child of node.children ?? []) visitDefinitions(child);
3219
+ };
3220
+ visitDefinitions(root);
3221
+ const replacements = [];
3222
+ const replaceNode = (node, replacement) => {
3223
+ const start = node.position?.start.offset;
3224
+ const end = node.position?.end.offset;
3225
+ if (start === void 0 || end === void 0) return false;
3226
+ replacements.push({ start, end, value: replacement });
3227
+ return true;
3228
+ };
3229
+ const visitUrls = (node) => {
3230
+ const hasUnsafeDirectUrl = (node.type === "link" || node.type === "image") && typeof node.url === "string" && !isSafePortableUrl(node.url);
3231
+ const hasUnsafeReference = (node.type === "linkReference" || node.type === "imageReference") && typeof node.identifier === "string" && unsafeDefinitions.has(node.identifier.toLowerCase());
3232
+ if (hasUnsafeDirectUrl || hasUnsafeReference) {
3233
+ if (replaceNode(node, escapeMarkdownText(markdownNodeText(node)))) return;
3234
+ }
3235
+ if (node.type === "definition" && node.identifier && unsafeDefinitions.has(node.identifier.toLowerCase())) {
3236
+ if (replaceNode(node, "")) return;
3237
+ }
3238
+ for (const child of node.children ?? []) visitUrls(child);
3239
+ };
3240
+ visitUrls(root);
3241
+ return replacements.sort((left, right) => right.start - left.start).reduce((result, replacement) => `${result.slice(0, replacement.start)}${replacement.value}${result.slice(replacement.end)}`, value);
3047
3242
  }
3048
3243
  function hasUnsafePortableUrlProp(value) {
3049
3244
  const quotedProps = new RegExp(`\\b(?:${PORTABLE_URL_PROPS})\\s*=\\s*(['"])([^'"\\r\\n]*)\\1`, "gi");
3050
3245
  for (const match of value.matchAll(quotedProps)) {
3051
- if (hasUnsafeUrlScheme(match[2])) return true;
3246
+ if (!isSafePortableUrl(decodeHtmlAttribute(match[2]))) return true;
3052
3247
  }
3053
3248
  const expressionProps = new RegExp(`\\b(?:${PORTABLE_URL_PROPS})\\s*=\\s*\\{\\s*("(?:\\\\.|[^"\\\\])*")\\s*\\}`, "gi");
3054
3249
  for (const match of value.matchAll(expressionProps)) {
3055
3250
  try {
3056
- if (hasUnsafeUrlScheme(JSON.parse(match[1]))) return true;
3251
+ if (!isSafePortableUrl(decodeHtmlAttribute(JSON.parse(match[1])))) return true;
3057
3252
  } catch {
3058
3253
  return true;
3059
3254
  }
@@ -3061,12 +3256,7 @@ function hasUnsafePortableUrlProp(value) {
3061
3256
  return false;
3062
3257
  }
3063
3258
  function portableHtmlToMarkdown(value) {
3064
- return value.replace(/<span\b[^>]*>|<\/span>/gi, "").replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (_original, attributes, label) => {
3065
- const href = attributes.match(/\bhref\s*=\s*(['"])(.*?)\1/i)?.[2];
3066
- const plainLabel = label.replace(/<[^>]+>/g, "").trim();
3067
- if (!href || hasUnsafeUrlScheme(href)) return plainLabel;
3068
- return `[${plainLabel.replace(/]/g, "\\]")}](${href})`;
3069
- }).replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_original, level, content) => {
3259
+ return replacePortableHtmlAnchors(replacePortableHtmlImages(value)).replace(/<span\b[^>]*>|<\/span>/gi, "").replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_original, level, content) => {
3070
3260
  return `
3071
3261
  ${"#".repeat(Number(level))} ${content.trim()}
3072
3262
  `;
@@ -3123,16 +3313,11 @@ function sanitizeRemoteMarkdown(body) {
3123
3313
  return line;
3124
3314
  }
3125
3315
  if (fenceCharacter) return line;
3126
- return portableHtmlToMarkdown(stripEventHandlerProps(line.replace(/!\[([^\]]*)\]\(\s*([^)]+)\)/g, (original, alternative, destination) => {
3127
- return hasUnsafeUrlScheme(destination.replace(/^<|>$/g, "")) ? alternative : original;
3128
- }).replace(/<img\s+([^>]*?)\/?\s*>/gi, (original, attributes) => {
3129
- const source = attributes.match(/\bsrc\s*=\s*(['"])(.*?)\1/i)?.[2];
3130
- const alternative = attributes.match(/\balt\s*=\s*(['"])(.*?)\1/i)?.[2] ?? "";
3131
- if (!source || hasUnsafeUrlScheme(source)) return "";
3132
- return `![${alternative.replace(/]/g, "\\]")}](${source})`;
3133
- })));
3316
+ return portableHtmlToMarkdown(stripEventHandlerProps(line.replace(/!\[([^\]]*)\]\(\s*([^)]+)\)/g, (original, alternative, destination) => isSafePortableUrl(decodeHtmlAttribute(destination.replace(/^<|>$/g, ""))) ? original : escapeMarkdownText(alternative))));
3134
3317
  }).join("\n");
3135
- const executable = withoutFencedCode(sanitized).replace(/\{\/\*[\s\S]*?\*\/\}/g, "").replace(/<(?:https?:\/\/|mailto:)[^>]+>/gi, "").replace(/(`+)[\s\S]*?\1/g, "");
3318
+ const urlSafeMarkdown = sanitizeMarkdownUrls(sanitized);
3319
+ if (urlSafeMarkdown === null) return null;
3320
+ const executable = withoutFencedCode(urlSafeMarkdown).replace(/\{\/\*[\s\S]*?\*\/\}/g, "").replace(/<(?:https?:\/\/|mailto:)[^>]+>/gi, "").replace(/(`+)[\s\S]*?\1/g, "");
3136
3321
  if (/^\s*(?:import|export)\s/m.test(executable)) return null;
3137
3322
  let hasUnsafeExpression = false;
3138
3323
  const withoutStaticProps = executable.replace(/=\{([^{}\n]*)\}/g, (original, value) => {
@@ -3149,7 +3334,7 @@ function sanitizeRemoteMarkdown(body) {
3149
3334
  const component = match[1].split(".", 1)[0];
3150
3335
  if (!PORTABLE_MDX_COMPONENTS.has(component)) return null;
3151
3336
  }
3152
- return sanitized;
3337
+ return urlSafeMarkdown;
3153
3338
  }
3154
3339
  function migratedHref(href, currentUrl, source, scopePath, importedIds, sourceHomePageId) {
3155
3340
  if (/^(?:javascript|data|vbscript):/i.test(href)) return "#";
@@ -3436,7 +3621,10 @@ ${tabs.join("\n\n")}
3436
3621
  filter: "table",
3437
3622
  replacement: (_content, node) => {
3438
3623
  const fragment = load(node.outerHTML);
3439
- const rows = fragment("tr").toArray().map((row) => fragment(row).children("th,td").toArray().map((cell) => fragment(cell).text().replace(/\s+/g, " ").replace(/\|/g, "\\|").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\{/g, "&#123;").replace(/\}/g, "&#125;").trim())).filter((row) => row.length > 0);
3624
+ const rows = fragment("tr").toArray().map((row) => fragment(row).children("th,td").toArray().map((cell) => escapeMarkdownText(
3625
+ fragment(cell).text().replace(/\s+/g, " ").trim(),
3626
+ true
3627
+ ))).filter((row) => row.length > 0);
3440
3628
  if (rows.length === 0) return "";
3441
3629
  const width = Math.max(...rows.map((row) => row.length));
3442
3630
  const normalized = rows.map((row) => [...row, ...Array(width - row.length).fill("")]);